Design - UI Layer
August 17, 2026
Last updated on August 18, 2026
Every dialog, pane, and toast in OneMore — from a modal settings sheet to the floating Navigator pane — is built on one base class: MoreForm (OneMore/UI/MoreForm.cs). This doc covers MoreForm and everything built on top of it: the modal and modeless dialog patterns,progress dialogs, and the reusable window/control building blocks. It does not cover the ThemeManager theming engine — that's its own design spec —though the hooks MoreForm exposes to it are noted here.
This layer exists because of where OneMore runs and what it doesn't have. As described in TechNote - COM Surrogate, the add-in is hosted inside a generic dllhost.exe COM surrogate process,not ONENOTE.EXE. Two consequences follow directly:
- OneMore has no native top-level window of its own, so it has nothing to set as a dialog's Owner. Modal parenting and centering have to borrow OneNote's own window handle instead.
- dllhost.exe never reads OneMore's manifest or App.config — Windows has no idea the process wants per-monitor DPI awareness or should ever own the foreground. Both have to be claimed at runtime, in code.
Architecture
MoreForm (OneMore/UI/MoreForm.cs) is a thin Form subclass that wires up the services every window needs and centralizes the Win32-level tricks that make a window hosted in a foreign process behave like a normal top-level window:
internal class MoreForm : Form, IOneMoreWindow
IOneMoreWindow is a marker interface (IDisposable, no members) used elsewhere to type-check "this is one of ours." The constructor sets the resx culture for localization and grabs two singletons every subclass needs — manager (ThemeManager.Instance) and logger(Logger.Current) — as protected readonly fields.
MoreUserControl (OneMore/UI/MoreUserControl.cs) is the UserControlcounterpart, used for settings sheets and embedded panels: same theming hookup, same OnThemeChange() hook, no window-level concerns (no owner,no modal/modeless split).
Key state on MoreForm subclasses configure by setting properties, not by overriding constructors:
|
Member |
Purpose |
|
DefaultControl (protected) |
Control focused once the form loads |
|
ManualLocation (public bool) |
Opt out of MoreForm's auto-centering in OnLoad |
|
ElevatedWithOneNote (public bool) |
Opt in to the focus-tracking/elevation behavior described below |
|
ThemeEnabled (protected bool, default true) |
Opt out of theming entirely (e.g. TimerWindow) |
|
VerticalOffset (public, write-only) |
Pixel offset applied after centering |
|
ModelessClosed (event) |
Fired from OnFormClosed; the completion callback for RunModeless |
Owning window and modal parenting
OneMore has no window of its own, so "the owner window" is always OneNote's own top-level HWND, sourced fresh through the COM layer:
- OneNote.WindowHandle / OneNote.OwnerWindow (OneMore/OneNote.cs)expose the active OneNote window's handle, wrapped as Win32WindowHandle : IWin32Window (OneMore/Helpers/Win32WindowHandle.cs)so it can be passed anywhere a Form.ShowDialog(IWin32Window) expects one.
- CommandFactory.Make<T>() / CommandFactory.Run(...)(OneMore/Commands/CommandFactory.cs) re-resolve one.OwnerWindow on every command invocation and inject it into the Command via SetOwner(owner). It is deliberately not cached — the previously active OneNote window may have already closed by the time the next command runs.
- MoreForm itself calls Native.GetWindowRect on OneNote.WindowHandleto compute a centered position, and holds onto that handle to hand focus back to OneNote when a modeless window closes (see below).
DPI and scaling
Handled separately from MoreForm, in OneMore/UI/Scaling.cs, because it has to happen once, early, at the process level:
- Scaling.PrepareUI() runs once from the AddIn constructor. It calls EnablePerMonitorDpiAwareness(), sets the EnableWindowsFormsHighDpiAutoResizing AppContext switch, and calls Application.EnableVisualStyles() / SetCompatibleTextRenderingDefault(false).
- EnablePerMonitorDpiAwareness() exists specifically because dllhost.exenever reads OneMore's manifest DPI declaration. It calls SetProcessDpiAwarenessContext / SetProcessDpiAwareness /SetProcessDPIAware directly, falling back through Windows-version tiers until one succeeds.
- Scaling.GetScalingFactors() / GetDpiValues() are called by individual dialogs (ProgressDialog, TimerWindow, ...) to manually rescale heights and icons, because WinForms' own auto-scale doesn't fully cover a per-monitor-aware window hosted this way.
Modal dialogs
There is no separate MoreDialog base class. A modal dialog is just a MoreForm subclass shown with the ordinary WinForms pattern, owned by the handle described above:
using var guard = EnterOnce();
if (guard is null) { return; }
using var dialog = new ToggleDttmDialog();
if (dialog.ShowDialog(owner) == DialogResult.OK)
{
await Toggle(dialog.PageOnly, dialog.ShowTimestamps);
}
Command.EnterOnce() (OneMore/Commands/Command.cs) is a per-command-type re-entrancy guard — a static HashSet<Type> — that stops the same dialog from being opened twice if the ribbon button or hotkey is triggered again while it's already showing. It's used the same way for both modal and modeless dialogs.
Representative examples: SearchAndReplaceDialog, NotebooksDialog,StyleDialog, PageColorDialog, ToggleDttmDialog, RenameDialog,TagPickerDialog, MoreColorDialog.
Modeless windows
Some dialogs — Search, the Navigator pane, hashtag popups — need to stay open while the user keeps working in OneNote, which a blocking ShowDialog() can't allow. MoreForm.RunModeless(...) is the shared mechanism:
public void RunModeless(EventHandler closedAction = null, int topDelta = 0)
public void RunModeless(Point location, EventHandler closedAction = null)
The first overload centers the form over the current OneNote window (offsetting vertically by topDelta% of the form's height if given); the second anchors it at an explicit screen point, for popups that need to appear next to the caret or selection (e.g. hashtag completion).
Both funnel into a shared core with a branch that matters for callers:
- If a message loop is already pumping on this thread (Application.MessageLoop == true — e.g. the command was invoked from HotkeyManager's own loop), it just calls Show() and returns immediately. Non-blocking.
- Otherwise it creates a private ApplicationContext and calls Application.Run(appContext), which blocks the calling stack until the form closes.
Either way, closedAction is wired to the ModelessClosed event, so callers get a completion callback regardless of which path was taken —and cleanup (releasing Command.EnterOnce()'s guard, disposing the dialog) belongs in that callback, not immediately after RunModeless()returns, since that call may not return until the window closes.
Staying on top of OneNote
A modeless OneMore window is a second top-level window competing with OneNote for focus, and Windows' foreground-window security model does not hand focus to a background process just because it asked. MoreFormsolves this with a trio of members — full mechanics (the AttachThreadInput dance and why it's needed) are written up in TechNote - Window Focus;summarized here:
- Elevate(bool keepTop = true) — brings the window to front, attaches input to the foreground thread long enough to call SetForegroundWindow, then detaches, then toggles TopMost off/on to force Windows to actually restack it.
- OnActivated calls Elevate(false) for modeless forms.
- ElevatedWithOneNote forms additionally register a UI Automation focus-changed handler in OnShown (added on a background Task.Run, since UI Automation setup can stall for seconds). Whenever focus moves back to the OneNote process, the handler calls Elevate() again so the OneMore window re-surfaces instead of getting buried. The handler is torn down symmetrically in OnFormClosed, with IsDisposed guards against races.
- OnFormClosed calls SetForegroundWindow on the stashed OneNote handle directly when a modeless window closes, handing focus back explicitly — without this, HotkeyManager's WndProc gate (which only dispatches WM_HOTKEY when the foreground window belongs to the OneNote process) silently stops responding to hotkeys.
Examples: NavigatorWindow (persistent floating pane, ElevatedWithOneNote,own position/splitter persistence via SettingsProvider), SearchDialog(modeless, ElevatedWithOneNote), TimerWindow (always-on-top rounded tool window, ThemeEnabled = false), CompleteHashtagDialog /HashtagDialog (anchored popups via the Point overload of RunModeless).
One gotcha worth calling out for anyone adding a new modeless window:WhereAmIWindow uses Show() directly rather than RunModeless(), and in that path setting DialogResult alone does not close the form —Close() has to be called explicitly.
Progress dialogs
ProgressDialog (OneMore/UI/ProgressDialog.cs) is a MoreForm subclass with three distinct modes, selected by constructor:
public ProgressDialog() // manual
public ProgressDialog(int maxSeconds) // timed
public ProgressDialog(Func<ProgressDialog, CancellationToken, Task> action) // background-execute
- Manual mode — the caller drives it directly: SetMaximum(int),Increment(), SetMessage(string) from inside its own loop, then closes the dialog itself. No cancel button. Typically shown with plain Show(), e.g. ToggleDttmCommand.Toggle's foreach loop over pages.
- Timed mode — paired with ShowTimedDialog(Func<ProgressDialog, CancellationToken, Task<bool>> action, bool cancelable = true).An internal Timer advances the bar each tick; the dialog closes with DialogResult.Abort when the timer reaches its maximum, or DialogResult.Cancel if the token is cancelled first.
- Background-execute mode — meant to be shown with RunModeless().The action delegate is started from OnShown via Task.Factory.StartNew, and the dialog closes itself when the task completes.
For modal use with a cancel button, there's a fourth path:ShowDialogWithCancel(Func<ProgressDialog, CancellationToken, Task<bool>> action, bool cancelable = true)runs action on a dedicated STA thread — required because OneNote's COM apartment is MTA, and running the work inline would conflict with it — then blocks the calling thread on the ordinary ShowDialog(). Cancelling calls source.Cancel() on the shared CancellationTokenSource and aborts the worker thread.
There's no IProgress<T> here — progress is reported by calling Increment() / SetMessage() / SetMaximum() directly on the ProgressDialog instance passed into the delegate.
Used throughout long-running commands: ArchiveCommand, CrawlWebPageCommand, ImportWebCommand, CreatePagesCommand.
Command framework boundary
Full command-framework mechanics (attribute-based registration,CommandProvider, dispatch, MRU/replay) are covered in Design - Command Frameworkand Design - Command Service.The boundary relevant here: CommandFactory injects each Command with owner (the IWin32Window described above), and Execute() constructs its dialog directly and either ShowDialog(owner)s or RunModeless()s it — there is no "show a form" abstraction inside the command framework itself. Command only supplies thin wrappers — ShowError, ShowInfo,ShowMessage, ConfirmSingleWindow — that delegate to MoreMessageBox.
Other reusable UI building blocks
All under OneMore/UI/:
- MoreMessageBox — MessageBox replacement/wrapper. Static helpers (Show, ShowError, ShowErrorWithLogLink, ShowQuestion,ShowWarning) all resolve to an instance ShowDialog(owner). Supports a "don't show again" suppression checkbox and a clickable "open log"link.
- MoreBubbleWindow — small rounded, timed fade-out toast; static MoreBubbleWindow.Show(text).
- WindowElevator — an invisible, zero-size helper Form used to force system CommonDialogs (e.g. ColorDialog) top-most on first show, since those never go through MoreForm at all.
- WebViewDialog — hosts a WebView2 control. Can run fully invisible (Opacity = 0) purely to give background WebView2 work an STA thread with a message pump, or visible (Opacity = 1.0) as an actual browser dialog.
- Themed control set — MoreButton, MoreTextBox, MoreComboBox,MoreCheckBox, MoreRadioButton, MoreLabel, MoreLinkLabel,MoreListView, MoreDataGridView, MoreGroupBox, MoreTabControl,MoreMenuStrip, MoreToolStrip, MorePanel, MoreExpander,MoreNumericUpDown, and others. Each implements IThemedControland/or ILoadControl so that MoreForm.OnLoad's control-tree walk and ThemeManager can theme and initialize them uniformly without each dialog wiring it up by hand. (Theming itself — light/dark/system/user modes, how ThemeManager resolves colors — is covered in its own design spec.)
References
- OneMore/UI/MoreForm.cs — base class: RunModeless, Elevate,OnActivated, OnLoad, OnShown, OnFormClosed, OnThemeChange
- OneMore/UI/MoreUserControl.cs — UserControl counterpart
- OneMore/UI/ProgressDialog.cs — manual / timed / background-execute / cancel-with-STA-thread modes
- OneMore/UI/Scaling.cs — per-monitor DPI awareness, manual rescaling
- OneMore/UI/MoreMessageBox.cs, MoreBubbleWindow.cs, WindowElevator.cs, WebViewDialog.cs
- OneMore/Helpers/Win32WindowHandle.cs — IWin32Window wrapper around a raw HWND
- OneMore/Commands/CommandFactory.cs — per-invocation owner resolution
- OneMore/Commands/Command.cs — EnterOnce(), message-box wrappers
- OneMore/Native.cs — Win32 P/Invoke surface (GetWindowRect, SetForegroundWindow, AttachThreadInput, DPI functions, ...)
- TechNote - Window Focus
- TechNote - COM Surrogate
- Design - Command Framework
#omwiki #omdeveloper #omdesign
© 2020 Steven M Cohn. All rights reserved.
Please consider a sponsorship or one-time donation to support ongoing development
Created with OneNote.